// This file is part of OpenTSDB.
// Copyright (C) 2010-2012  The OpenTSDB Authors.
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU Lesser General Public License as published by
// the Free Software Foundation, either version 2.1 of the License, or (at your
// option) any later version.  This program is distributed in the hope that it
// will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
// of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser
// General Public License for more details.  You should have received a copy
// of the GNU Lesser General Public License along with this program.  If not,
// see <http://www.gnu.org/licenses/>.
package net.opentsdb.tsd.client;

import com.google.gwt.http.client.RequestBuilder;
import com.google.gwt.http.client.RequestCallback;
import com.google.gwt.http.client.RequestException;
import com.google.gwt.json.client.JSONArray;
import com.google.gwt.json.client.JSONParser;
import com.google.gwt.json.client.JSONValue;
import com.google.gwt.user.client.ui.*;

import java.util.HashMap;

/**
 * An oracle that gets suggestions through an AJAX call and provides caching.
 * <p/>
 * The oracle builds up a local cache of known suggestions and tries to avoid
 * unnecessary requests when the cache can be used (which is fairly frequent
 * given the typing pattern) or when we know for sure there won't be any
 * results.
 * <p/>
 * The oracle is given a type.  Every instance that share the same type also
 * share the same caches under the hood.  This is convenient when you want to
 * have multiple text boxes with the same type of suggestions.
 */
final class RemoteOracle extends SuggestOracle {

    private static final String SUGGEST_URL = "/suggest?type=";  // + type&q=foo

    /**
     * Maps an oracle type to its suggestion cache.
     * The cache is in fact a {@link MultiWordSuggestOracle}, which we re-use as
     * its implementation is good (it uses a trie, handles HTML formatting etc.).
     */
    private static final HashMap<String, MultiWordSuggestOracle> caches =
            new HashMap<String, MultiWordSuggestOracle>();

    /**
     * Maps an oracle type to the queries recently seen for this type.
     */
    private static final HashMap<String, QueriesSeen> all_queries_seen =
            new HashMap<String, QueriesSeen>();

    private final String type;
    private final MultiWordSuggestOracle cache;
    private final QueriesSeen queries_seen;

    /**
     * Which widget are we wrapping to provide suggestions.
     */
    private HasText requester;
    /**
     * Current ongoing request, or null.
     */
    private Callback current;

    /**
     * Pending request that arrived while we were still processing `current'.
     * If requests keep coming in while we're processing `current', the last
     * pending one will overwrite the previous pending one.
     */
    private Request pending_req;
    private Callback pending_cb;

    /**
     * Used to guess whether we need to fetch more suggestions.
     */
    private String last_query;
    private String last_suggestion;

    /**
     * Factory method to use in order to get a {@link net.opentsdb.tsd.client.RemoteOracle} instance.
     *
     * @param suggest_type The type of suggestion wanted.
     * @param textbox      The text box to wrap to provide suggestions to.
     */
    public static SuggestBox newSuggestBox(final String suggest_type,
                                           final TextBoxBase textbox) {
        final RemoteOracle oracle = new RemoteOracle(suggest_type);
        final SuggestBox box = new SuggestBox(oracle, textbox);
        oracle.requester = box;
        return box;
    }

    /**
     * Private constructor, use {@link #newSuggestBox} instead.
     */
    private RemoteOracle(final String suggest_type) {
        type = suggest_type;
        MultiWordSuggestOracle cache = caches.get(type);
        QueriesSeen queries_seen;
        if (cache == null) {
            cache = new MultiWordSuggestOracle(".");
            queries_seen = new QueriesSeen();
            caches.put(type, cache);
            all_queries_seen.put(type, queries_seen);
        } else {
            queries_seen = all_queries_seen.get(type);
        }
        this.cache = cache;
        this.queries_seen = queries_seen;
    }

    @Override
    public boolean isDisplayStringHTML() {
        return true;
    }

    @Override
    public void requestSuggestions(final Request request, final Callback callback) {
        if (current != null) {
            pending_req = request;
            pending_cb = callback;
            return;
        }
        current = callback;
        {
            final String this_query = request.getQuery();
            // Check if we can serve this from our local cache, without even talking
            // to the server.  This is possible if either of those is true:
            //   1. We've already seen this query recently.
            //   2. This new query precedes another one and the user basically just
            //      typed another letter, so if the new query is "less than" the last
            //      result we got from the server, we know we already cached the full
            //      range of results covering the new request.
            if ((last_query != null
                    && last_query.compareTo(this_query) <= 0
                    && this_query.compareTo(last_suggestion) < 0)
                    || queries_seen.check(this_query)) {
                current = null;
                cache.requestSuggestions(request, callback);
                return;
            }
            last_query = this_query;
        }

        final RequestBuilder builder = new RequestBuilder(RequestBuilder.GET,
                SUGGEST_URL + type + "&q=" + last_query);
        try {
            builder.sendRequest(null, new RequestCallback() {
                public void onError(final com.google.gwt.http.client.Request r,
                                    final Throwable e) {
                    current = null;  // Something bad happened, drop the current request.
                    if (pending_req != null) {  // But if we have another waiting...
                        requestSuggestions(pending_req, pending_cb);  // ... try it now.
                    }
                }

                // Need to use fully-qualified names as this class inherits already
                // from a pair of inner classes called Request / Response :-/
                public void onResponseReceived(final com.google.gwt.http.client.Request r,
                                               final com.google.gwt.http.client.Response response) {
                    if (response.getStatusCode() == com.google.gwt.http.client.Response.SC_OK) {
                        final JSONValue json = JSONParser.parse(response.getText());
                        // In case this request returned nothing, we pretend the last
                        // suggestion ended with the largest character possible, so we
                        // won't send more requests to the server if the user keeps
                        // adding extra characters.
                        last_suggestion = last_query + "\377";
                        if (json != null && json.isArray() != null) {
                            final JSONArray results = json.isArray();
                            final int n = Math.min(request.getLimit(), results.size());
                            for (int i = 0; i < n; i++) {
                                final JSONValue suggestion = results.get(i);
                                if (suggestion == null || suggestion.isString() == null) {
                                    continue;
                                }
                                final String suggestionstr = suggestion.isString().stringValue();
                                last_suggestion = suggestionstr;
                                cache.add(suggestionstr);
                            }
                            // Is this response still relevant to what the requester wants?
                            if (requester.getText().startsWith(last_query)) {
                                cache.requestSuggestions(request, callback);
                                pending_req = null;
                                pending_cb = null;
                            }
                        }
                    }
                    current = null;  // Regardless of what happened above, this is done.
                    if (pending_req != null) {
                        final Request req = pending_req;
                        final Callback cb = pending_cb;
                        pending_req = null;
                        pending_cb = null;
                        requestSuggestions(req, cb);
                    }
                }
            });
        } catch (RequestException ignore) {
        }
    }

    /**
     * Small circular buffer of queries already typed by the user.
     */
    private static final class QueriesSeen {

        /**
         * A circular buffer containing the last few requests already served.
         * It would be awesome if {@code gwt.user.client.ui.PrefixTree} wasn't
         * package-private, so we could use that instead.
         */
        private final String[] already_requested = new String[128];
        private int already_index;  // Index into already_index.

        /**
         * Checks whether or not we've already seen that query.
         */
        boolean check(final String query) {
            // Check most recent queries first, as they're the most likely to match
            // if the user goes back and forth by typing a few characters, removing
            // some, typing some more, etc.
            for (int i = already_index - 1; i >= 0; i--) {
                if (query.equals(already_requested[i])) {
                    return true;
                }
            }
            for (int i = already_requested.length - 1; i >= already_index; i--) {
                if (query.equals(already_requested[i])) {
                    return true;
                }
            }

            // First time we see this query, let's record it.
            already_requested[already_index++] = query;
            if (already_index == already_requested.length) {
                already_index = 0;
            }
            return false;
        }

    }

}
